#!/usr/bin/python
# (c) 2014, Kevin Carter <kevin.carter@rackspace.com>
#
# Copyright 2014, Rackspace US, Inc.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
#     http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.

import base64
import os
import stat
import sys

import memcache
try:
    from Crypto.Cipher import AES
    from Crypto import Random

    ENCRYPT_IMPORT = True
except ImportError:
    ENCRYPT_IMPORT = False

# import module snippets
from ansible.module_utils.basic import *

DOCUMENTATION = """
---
module: memcached
version_added: "1.6.6"
short_description:
   - Add, remove, and get items from memcached
description:
   - Add, remove, and get items from memcached
options:
    name:
        description:
            - Memcached key name
        required: true
    content:
        description:
            - Add content to memcached. Only used when state is 'present'.
        required: false
    file_path:
        description:
            - This can be used with state 'present' and 'retrieve'. When set
              with state 'present' the contents of a file will be used, when
              set with state 'retrieve' the contents of the memcached key will
              be written to a file.
        required: false
    state:
        description:
            - ['absent', 'present', 'retrieve']
        required: true
    server:
        description:
            - server IP address and port. This can be a comma separated list of
              servers to connect to.
        required: true
    encrypt_string:
        description:
            - Encrypt/Decrypt a memcached object using a provided value.
        required: false
    dir_mode:
        description:
            - If a directory is created when using the ``file_path`` argument
              the directory will be created with a set mode.
        default: '0755'
        required: false
    file_mode:
        description:
            - If a file is created when using the ``file_path`` argument
              the file will be created with a set mode.
        default: '0644'
        required: false
    expires:
        description:
            - Seconds until an item is expired from memcached.
        default: 300
        required: false
notes:
    - The "absent" state will remove an item from memcached.
    - The "present" state will place an item from a string or a file into
      memcached.
    - The "retrieve" state will get an item from memcached and return it as a
      string. If a ``file_path`` is set this module will also write the value
      to a file.
    - All items added into memcached are base64 encoded.
    - All items retrieved will attempt base64 decode and return the string
      value if not applicable.
    - Items retrieve from memcached are returned within a "value" key unless
      a ``file_path`` is specified which would then write the contents of the
      memcached key to a file.
    - The ``file_path`` and ``content`` fields are mutually exclusive.
    - If you'd like to encrypt items in memcached PyCrypto is a required.
requirements:
    - "python-memcached"
optional_requirements:
    - "pycrypto"
author: Kevin Carter
"""

EXAMPLES = """
# Add an item into memcached.
- memcached:
    name: "key_name"
    content: "Super awesome value"
    state: "present"
    server: "localhost:11211"

# Read the contents of a memcached key, returned as "memcached_phrase.value".
- memcached:
    name: "key_name"
    state: "retrieve"
    server: "localhost:11211"
    register: memcached_key

# Add the contents of a file into memcached.
- memcached:
    name: "key_name"
    file_path: "/home/user_name/file.txt"
    state: "present"
    server: "localhost:11211"

# Write the contents of a memcached key to a file and is returned as
# "memcached_phrase.value".
- memcached:
    name: "key_name"
    file_path: "/home/user_name/file.txt"
    state: "retrieve"
    server: "localhost:11211"
    register: memcached_key

# Delete an item from memcached.
- memcached:
    name: "key_name"
    state: "absent"
    server: "localhost:11211"
"""

SERVER_MAX_VALUE_LENGTH = 1024 * 256

MAX_MEMCACHED_CHUNKS = 256


class AESCipher(object):
    """Encrypt an a string in using AES.

    Solution derived from "http://stackoverflow.com/a/21928790"
    """
    def __init__(self, key):
        if ENCRYPT_IMPORT is False:
            raise ImportError(
                'PyCrypto failed to be imported. Encryption is not supported'
                ' on this system until PyCrypto is installed.'
            )

        self.bs = 32
        if len(key) >= 32:
            self.key = key[:32]
        else:
            self.key = self._pad(key)

    def encrypt(self, raw):
        """Encrypt raw message.

        :param raw: ``str``
        :returns: ``str``  Base64 encoded string.
        """
        raw = self._pad(raw)
        iv = Random.new().read(AES.block_size)
        cipher = AES.new(self.key, AES.MODE_CBC, iv)
        return base64.b64encode(iv + cipher.encrypt(raw))

    def decrypt(self, enc):
        """Decrypt an encrypted message.

        :param enc: ``str``
        :returns: ``str``
        """
        enc = base64.b64decode(enc)
        iv = enc[:AES.block_size]
        cipher = AES.new(self.key, AES.MODE_CBC, iv)
        return self._unpad(cipher.decrypt(enc[AES.block_size:]))

    def _pad(self, string):
        """Pad an AES encryption key.

        :param string: ``str``
        """
        base = (self.bs - len(string) % self.bs)
        back = chr(self.bs - len(string) % self.bs)
        return string + base * back

    @staticmethod
    def _unpad(string):
        """Un-pad an AES encryption key.

        :param string: ``str``
        """
        ordinal_range = ord(string[len(string) - 1:])
        return string[:-ordinal_range]


class Memcached(object):
    """Manage objects within memcached."""
    def __init__(self, module):
        self.module = module
        self.state_change = False
        self.mc = None

    def router(self):
        """Route all commands to their respected functions.

        If an exception happens a failure will be raised.
        """

        try:
            action = getattr(self, self.module.params['state'])
            self.mc = memcache.Client(
                self.module.params['server'].split(','),
                server_max_value_length=SERVER_MAX_VALUE_LENGTH,
                debug=0
            )
            facts = action()
        except Exception as exp:
            self._failure(error=str(exp), rc=1, msg='general exception')
        else:
            self.mc.disconnect_all()
            self.module.exit_json(
                changed=self.state_change, **facts
            )

    def _failure(self, error, rc, msg):
        """Return a Failure when running an Ansible command.

        :param error: ``str``  Error that occurred.
        :param rc: ``int``     Return code while executing an Ansible command.
        :param msg: ``str``    Message to report.
        """

        self.module.fail_json(msg=msg, rc=rc, err=error)

    def absent(self):
        """Remove a key from memcached.

        If the value is not deleted when instructed to do so an exception will
        be raised.

        :return: ``dict``
        """

        key_name = self.module.params['name']
        get_keys = [
            '%s.%s' % (key_name, i) for i in xrange(MAX_MEMCACHED_CHUNKS)
        ]
        self.mc.delete_multi(get_keys)
        value = self.mc.get_multi(get_keys)
        if not value:
            self.state_change = True
            return {'absent': True, 'key': self.module.params['name']}
        else:
            self._failure(
                error='Memcache key not deleted',
                rc=1,
                msg='Failed to remove an item from memcached please check your'
                    ' memcached server for issues. If you are load balancing'
                    ' memcached, attempt to connect to a single node.'
            )

    @staticmethod
    def _decode_value(value):
        """Return a ``str`` from a base64 decoded value.

        If the content is not a base64 ``str`` the raw value will be returned.

        :param value: ``str``
        :return:
        """

        try:
            b64_value = base64.decodestring(value)
        except Exception:
            return value
        else:
            return b64_value

    def _encode_value(self, value):
        """Return a base64 encoded value.

        If the value can't be base64 encoded an excption will be raised.

        :param value: ``str``
        :return: ``str``
        """

        try:
            b64_value = base64.encodestring(value)
        except Exception as exp:
            self._failure(
                error=str(exp),
                rc=1,
                msg='The value provided can not be Base64 encoded.'
            )
        else:
            return b64_value

    def _file_read(self, full_path, pass_on_error=False):
        """Read the contents of a file.

        This will read the contents of a file. If the ``full_path`` does not
        exist an exception will be raised.

        :param full_path: ``str``
        :return: ``str``
        """

        try:
            with open(full_path, 'rb') as f:
                o_value = f.read()
        except IOError as exp:
            if pass_on_error is False:
                self._failure(
                    error=str(exp),
                    rc=1,
                    msg="The file you've specified does not exist. Please"
                        " check your full path @ [ %s ]." % full_path
                )
            else:
                return None
        else:
            return o_value

    def _chown(self, path, mode_type):
        """Chown a file or directory based on a given mode type.

        If the file is modified the state will be changed.

        :param path: ``str``
        :param mode_type: ``str``
        """
        mode = self.module.params.get(mode_type)
        # Ensure that the mode type is a string.
        mode = str(mode)
        _mode = oct(stat.S_IMODE(os.stat(path).st_mode))
        if _mode != mode or _mode[1:] != mode:
            os.chmod(path, int(mode, 8))
            self.state_change = True

    def _file_write(self, full_path, value):
        """Write the contents of ``value`` to the ``full_path``.

        This will return True upon success and will raise an exception upon
        failure.

        :param full_path: ``str``
        :param value: ``str``
        :return: ``bol``
        """

        try:
            # Ensure that the directory exists
            dir_path = os.path.dirname(full_path)
            try:
                os.makedirs(dir_path)
            except OSError as exp:
                if exp.errno == errno.EEXIST and os.path.isdir(dir_path):
                    pass
                else:
                    self._failure(
                        error=str(exp),
                        rc=1,
                        msg="The directory [ %s ] does not exist and couldn't"
                            " be created. Please check the path and that you"
                            " have permission to write the file."
                    )

            # Ensure proper directory permissions
            self._chown(path=dir_path, mode_type='dir_mode')

            # Write contents of a cached key to a file.
            with open(full_path, 'wb') as f:
                if isinstance(value, list):
                    f.writelines(value)
                else:
                    f.write(value)

            # Ensure proper file permissions
            self._chown(path=full_path, mode_type='file_mode')

        except IOError as exp:
            self._failure(
                error=str(exp),
                rc=1,
                msg="There was an issue while attempting to write to the"
                    " file [ %s ]. Please check your full path and"
                    " permissions." % full_path
            )
        else:
            return True

    def retrieve(self):
        """Return a value from memcached.

        If ``file_path`` is specified the value of the memcached key will be
        written to a file at the ``file_path`` location. If the value of a key
        is None, an exception will be raised.

        :returns: ``dict``
        """

        key_name = self.module.params['name']
        get_keys = [
            '%s.%s' % (key_name, i) for i in xrange(MAX_MEMCACHED_CHUNKS)
        ]
        multi_value = self.mc.get_multi(get_keys)
        if multi_value:
            value = ''.join([i for i in multi_value.values() if i is not None])
            # Get the file path if specified.
            file_path = self.module.params.get('file_path')
            if file_path is not None:
                full_path = os.path.abspath(os.path.expanduser(file_path))

                # Decode cached value
                encrypt_string = self.module.params.get('encrypt_string')
                if encrypt_string:
                    _d_value = AESCipher(key=encrypt_string)
                    d_value = _d_value.decrypt(enc=value)
                    if not d_value:
                        d_value = self._decode_value(value=value)
                else:
                    d_value = self._decode_value(value=value)

                o_value = self._file_read(
                    full_path=full_path, pass_on_error=True
                )

                # compare old value to new value and write if different
                if o_value != d_value:
                    self.state_change = True
                    self._file_write(full_path=full_path, value=d_value)

                return {
                    'present': True,
                    'key': self.module.params['name'],
                    'value': value,
                    'file_path': full_path
                }
            else:
                return {
                    'present': True,
                    'key': self.module.params['name'],
                    'value': value
                }
        else:
            self._failure(
                error='Memcache key not found',
                rc=1,
                msg='The key you specified was not found within memcached. '
                    'If you are load balancing memcached, attempt to connect'
                    ' to a single node.'
            )

    def present(self):
        """Create and or update a key within Memcached.

        The state processed here is present. This state will ensure that
        content is written to a memcached server. When ``file_path`` is
        specified the content will be read in from a file.
        """

        file_path = self.module.params.get('file_path')
        if file_path is not None:
            full_path = os.path.abspath(os.path.expanduser(file_path))
            # Read the contents of a file into memcached.
            o_value = self._file_read(full_path=full_path)
        else:
            o_value = self.module.params['content']

        # Encode cached value
        encrypt_string = self.module.params.get('encrypt_string')
        if encrypt_string:
            _d_value = AESCipher(key=encrypt_string)
            d_value = _d_value.encrypt(raw=o_value)
        else:
            d_value = self._encode_value(value=o_value)

        compare = 1024 * 128
        chunks = sys.getsizeof(d_value) / compare
        if chunks == 0:
            chunks = 1
        elif chunks > MAX_MEMCACHED_CHUNKS:
            self._failure(
                error='Memcache content too large',
                rc=1,
                msg='The content that you are attempting to cache is larger'
                    ' than [ %s ] megabytes.'
                    % ((compare * MAX_MEMCACHED_CHUNKS / 1024 / 1024))
            )

        step = len(d_value) / chunks
        if step == 0:
            step = 1

        key_name = self.module.params['name']
        split_d_value = {}
        count = 0
        for i in range(0, len(d_value), step):
            split_d_value['%s.%s' % (key_name, count)] = d_value[i:i + step]
            count += 1

        value = self.mc.set_multi(
            mapping=split_d_value,
            time=self.module.params['expires'],
            min_compress_len=2048
        )

        if not value:
            self.state_change = True
            return {
                'present': True,
                'key': self.module.params['name']
            }
        else:
            self._failure(
                error='Memcache content not created',
                rc=1,
                msg='The content you attempted to place within memcached'
                    ' was not created. If you are load balancing'
                    ' memcached, attempt to connect to a single node.'
                    ' Returned a value of unstored keys [ %s ] - Original'
                    ' Connection [ %s ]'
                    % (value, [i.__dict__ for i in self.mc.servers])
            )


def main():
    """Main ansible run method."""
    module = AnsibleModule(
        argument_spec=dict(
            name=dict(
                type='str',
                required=True
            ),
            content=dict(
                type='str',
                required=False
            ),
            file_path=dict(
                type='str',
                required=False
            ),
            state=dict(
                type='str',
                required=True
            ),
            server=dict(
                type='str',
                required=True
            ),
            expires=dict(
                type='int',
                default=300,
                required=False
            ),
            file_mode=dict(
                type='str',
                default='0644',
                required=False
            ),
            dir_mode=dict(
                type='str',
                default='0755',
                required=False
            ),
            encrypt_string=dict(
                type='str',
                required=False
            )
        ),
        supports_check_mode=False,
        mutually_exclusive=[
            ['content', 'file_path']
        ]
    )
    ms = Memcached(module=module)
    ms.router()

if __name__ == '__main__':
    main()
